new.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439
  1. // @ts-nocheck
  2. import { zodResolver } from '@hookform/resolvers/zod'
  3. import { useParams } from 'common'
  4. import { isEqual } from 'lodash'
  5. import { AlertCircle, Book, Check } from 'lucide-react'
  6. import { useRouter } from 'next/router'
  7. import { useEffect, useId, useMemo, useState } from 'react'
  8. import { useForm } from 'react-hook-form'
  9. import { toast } from 'sonner'
  10. import {
  11. AiIconAnimation,
  12. Button,
  13. cn,
  14. Command,
  15. CommandEmpty,
  16. CommandGroup,
  17. CommandInput,
  18. CommandItem,
  19. CommandList,
  20. Form,
  21. FormControl,
  22. FormField,
  23. FormItem,
  24. Input,
  25. Label,
  26. Popover,
  27. PopoverContent,
  28. PopoverTrigger,
  29. Tooltip,
  30. TooltipContent,
  31. TooltipTrigger,
  32. } from 'ui'
  33. import * as z from 'zod'
  34. import { EDGE_FUNCTION_TEMPLATES } from '@/components/interfaces/Functions/Functions.templates'
  35. import { DefaultLayout } from '@/components/layouts/DefaultLayout'
  36. import EdgeFunctionsLayout from '@/components/layouts/EdgeFunctionsLayout/EdgeFunctionsLayout'
  37. import { PageLayout } from '@/components/layouts/PageLayout/PageLayout'
  38. import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
  39. import { PreventNavigationOnUnsavedChanges } from '@/components/ui-patterns/Dialogs/PreventNavigationOnUnsavedChanges'
  40. import { FileExplorerAndEditor } from '@/components/ui/FileExplorerAndEditor'
  41. import { FileData } from '@/components/ui/FileExplorerAndEditor/FileExplorerAndEditor.types'
  42. import { useEdgeFunctionDeployMutation } from '@/data/edge-functions/edge-functions-deploy-mutation'
  43. import { useSendEventMutation } from '@/data/telemetry/send-event-mutation'
  44. import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
  45. import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
  46. import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
  47. import { BASE_PATH } from '@/lib/constants'
  48. import { useAiAssistantStateSnapshot } from '@/state/ai-assistant-state'
  49. import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state'
  50. // Array of adjectives and nouns for random function name generation
  51. const ADJECTIVES = [
  52. 'quick',
  53. 'clever',
  54. 'bright',
  55. 'swift',
  56. 'rapid',
  57. 'smart',
  58. 'smooth',
  59. 'dynamic',
  60. 'super',
  61. 'hyper',
  62. ]
  63. const NOUNS = [
  64. 'function',
  65. 'handler',
  66. 'processor',
  67. 'responder',
  68. 'worker',
  69. 'service',
  70. 'api',
  71. 'endpoint',
  72. 'action',
  73. 'task',
  74. ]
  75. // Function name validation regex - only allows alphanumeric characters, hyphens, and underscores
  76. const FUNCTION_NAME_REGEX = /^[A-Za-z0-9_-]+$/
  77. // Define form schema with zod
  78. const FormSchema = z.object({
  79. functionName: z
  80. .string()
  81. .min(1, 'Function name is required')
  82. .regex(FUNCTION_NAME_REGEX, 'Only letters, numbers, hyphens, and underscores allowed'),
  83. })
  84. // Generate a random function name
  85. const generateRandomFunctionName = () => {
  86. const adjective = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)]
  87. const noun = NOUNS[Math.floor(Math.random() * NOUNS.length)]
  88. return `${adjective}-${noun}`
  89. }
  90. // Convert invalid function name to valid one
  91. const sanitizeFunctionName = (name: string): string => {
  92. // Replace invalid characters with hyphens
  93. return name.replace(/[^A-Za-z0-9_-]/g, '-')
  94. }
  95. // Type for the form values
  96. type FormValues = z.infer<typeof FormSchema>
  97. const INITIAL_FILES: FileData[] = [
  98. {
  99. id: 1,
  100. name: 'index.ts',
  101. content: EDGE_FUNCTION_TEMPLATES[0].content,
  102. state: 'new',
  103. },
  104. ]
  105. const NewFunctionPage = () => {
  106. const router = useRouter()
  107. const { ref, template } = useParams()
  108. const { data: project } = useSelectedProjectQuery()
  109. const { data: org } = useSelectedOrganizationQuery()
  110. const snap = useAiAssistantStateSnapshot()
  111. const { mutate: sendEvent } = useSendEventMutation()
  112. const showStripeExample = useIsFeatureEnabled('edge_functions:show_stripe_example')
  113. const { openSidebar } = useSidebarManagerSnapshot()
  114. const [files, setFiles] = useState<FileData[]>(INITIAL_FILES)
  115. const [selectedFileId, setSelectedFileId] = useState<number>(INITIAL_FILES[0].id)
  116. const [open, setOpen] = useState(false)
  117. const templatesListboxId = useId()
  118. const [isPreviewingTemplate, setIsPreviewingTemplate] = useState(false)
  119. const [savedCode, setSavedCode] = useState<string>('')
  120. const templates = useMemo(() => {
  121. if (showStripeExample) {
  122. return EDGE_FUNCTION_TEMPLATES
  123. }
  124. // Filter out Stripe template
  125. return EDGE_FUNCTION_TEMPLATES.filter((template) => template.value !== 'stripe-webhook')
  126. }, [showStripeExample])
  127. const form = useForm<FormValues>({
  128. resolver: zodResolver(FormSchema as any),
  129. defaultValues: {
  130. functionName: generateRandomFunctionName(),
  131. },
  132. })
  133. const {
  134. mutate: deployFunction,
  135. isPending: isDeploying,
  136. isSuccess: hasDeployed,
  137. } = useEdgeFunctionDeployMutation({
  138. // [Joshen] To investigate: For some reason, the invalidation for list of edge functions isn't triggering
  139. onSuccess: () => {
  140. toast.success('Successfully deployed edge function')
  141. const functionName = form.getValues('functionName')
  142. // Allow the mutation state (isSuccess) to propagate before navigating
  143. // to prevent unnecessary dialog about unsaved changes
  144. setTimeout(() => {
  145. if (ref && functionName) {
  146. router.push(`/project/${ref}/functions/${functionName}/details`)
  147. }
  148. }, 150)
  149. },
  150. })
  151. const onSubmit = (values: FormValues) => {
  152. if (isDeploying || !ref) return
  153. deployFunction({
  154. projectRef: ref,
  155. slug: values.functionName,
  156. metadata: { name: values.functionName, verify_jwt: true },
  157. files: files.map(({ name, content }) => ({ name, content })),
  158. })
  159. sendEvent({
  160. action: 'edge_function_deploy_button_clicked',
  161. properties: { origin: 'functions_editor' },
  162. groups: { project: ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' },
  163. })
  164. }
  165. const handleChat = () => {
  166. const selectedFile = files.find((f) => f.id === selectedFileId)
  167. openSidebar(SIDEBAR_KEYS.AI_ASSISTANT)
  168. snap.newChat({
  169. name: 'Explain edge function',
  170. sqlSnippets: [selectedFile?.content ?? ''],
  171. initialInput: 'Help me understand and improve this edge function...',
  172. suggestions: {
  173. title:
  174. 'I can help you understand and improve your edge function. Here are a few example prompts to get you started:',
  175. prompts: [
  176. {
  177. label: 'Explain Function',
  178. description: 'Explain what this function does...',
  179. },
  180. {
  181. label: 'Optimize Function',
  182. description: 'Help me optimize this function...',
  183. },
  184. {
  185. label: 'Add Features',
  186. description: 'Show me how to add more features...',
  187. },
  188. {
  189. label: 'Error Handling',
  190. description: 'Help me handle errors better...',
  191. },
  192. ],
  193. },
  194. })
  195. sendEvent({
  196. action: 'edge_function_ai_assistant_button_clicked',
  197. properties: { origin: 'functions_editor_chat' },
  198. groups: { project: ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' },
  199. })
  200. }
  201. const onSelectTemplate = (templateValue: string) => {
  202. const template = EDGE_FUNCTION_TEMPLATES.find((t) => t.value === templateValue)
  203. if (template) {
  204. setFiles((prev) =>
  205. prev.map((file) =>
  206. file.id === selectedFileId ? { ...file, content: template.content } : file
  207. )
  208. )
  209. setOpen(false)
  210. sendEvent({
  211. action: 'edge_function_template_clicked',
  212. properties: { templateName: template.name, origin: 'editor_page' },
  213. groups: { project: ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' },
  214. })
  215. }
  216. setIsPreviewingTemplate(false)
  217. }
  218. const handleTemplateMouseEnter = (content: string) => {
  219. if (!isPreviewingTemplate) {
  220. const selectedFile = files.find((f) => f.id === selectedFileId) ?? files[0]
  221. setSavedCode(selectedFile.content)
  222. }
  223. setIsPreviewingTemplate(true)
  224. setFiles((prev) => prev.map((f) => (f.id === selectedFileId ? { ...f, content } : f)))
  225. }
  226. const handleTemplateMouseLeave = () => {
  227. if (isPreviewingTemplate) {
  228. setIsPreviewingTemplate(false)
  229. setFiles((prev) =>
  230. prev.map((f) => (f.id === selectedFileId ? { ...f, content: savedCode } : f))
  231. )
  232. }
  233. }
  234. // Try to sanitize function name when it's invalid
  235. const handleDeploy = () => {
  236. const currentName = form.getValues('functionName')
  237. const isValid = FUNCTION_NAME_REGEX.test(currentName)
  238. if (!isValid && currentName) {
  239. const sanitizedName = sanitizeFunctionName(currentName)
  240. form.setValue('functionName', sanitizedName, { shouldValidate: true })
  241. }
  242. form.handleSubmit(onSubmit)()
  243. }
  244. useEffect(() => {
  245. if (template) {
  246. const templateMeta = EDGE_FUNCTION_TEMPLATES.find((x) => x.value === template)
  247. if (templateMeta) {
  248. form.reset({ functionName: template })
  249. setSelectedFileId(1)
  250. setFiles([
  251. {
  252. id: 1,
  253. name: 'index.ts',
  254. content: templateMeta.content,
  255. state: 'new',
  256. },
  257. ])
  258. }
  259. }
  260. // eslint-disable-next-line react-hooks/exhaustive-deps
  261. }, [template])
  262. const hasUnsavedChanges = useMemo(() => !isEqual(INITIAL_FILES, files), [files])
  263. return (
  264. <PageLayout
  265. size="full"
  266. isCompact
  267. title="Create new edge function"
  268. breadcrumbs={[
  269. {
  270. label: 'Edge Functions',
  271. href: `/project/${ref}/functions`,
  272. },
  273. ]}
  274. primaryActions={
  275. <>
  276. <Popover open={open} onOpenChange={setOpen}>
  277. <PopoverTrigger asChild>
  278. <Button
  279. size="tiny"
  280. type="default"
  281. role="combobox"
  282. aria-expanded={open}
  283. aria-controls={templatesListboxId}
  284. icon={<Book size={14} />}
  285. >
  286. Templates
  287. </Button>
  288. </PopoverTrigger>
  289. <PopoverContent id={templatesListboxId} className="w-[300px] p-0" align="end">
  290. <Command>
  291. <CommandInput placeholder="Search templates..." />
  292. <CommandList>
  293. <CommandEmpty>No templates found.</CommandEmpty>
  294. <CommandGroup>
  295. {templates.map((template) => (
  296. <CommandItem
  297. key={template.value}
  298. value={template.value}
  299. onSelect={onSelectTemplate}
  300. onMouseEnter={() => handleTemplateMouseEnter(template.content)}
  301. onMouseLeave={handleTemplateMouseLeave}
  302. className="cursor-pointer"
  303. >
  304. <div className="flex flex-col gap-1">
  305. <div className="flex items-center">
  306. <Check
  307. className={cn(
  308. 'mr-2 h-4 w-4',
  309. files.some((f) => f.content === template.content)
  310. ? 'opacity-100'
  311. : 'opacity-0'
  312. )}
  313. />
  314. <span className="text-foreground">{template.name}</span>
  315. </div>
  316. <span className="text-xs text-foreground-light pl-6">
  317. {template.description}
  318. </span>
  319. </div>
  320. </CommandItem>
  321. ))}
  322. </CommandGroup>
  323. </CommandList>
  324. </Command>
  325. </PopoverContent>
  326. </Popover>
  327. <Button
  328. size="tiny"
  329. type="default"
  330. onClick={handleChat}
  331. icon={<AiIconAnimation size={16} />}
  332. >
  333. Chat
  334. </Button>
  335. </>
  336. }
  337. >
  338. <FileExplorerAndEditor
  339. files={files}
  340. onFilesChange={setFiles}
  341. aiEndpoint={`${BASE_PATH}/api/ai/code/complete`}
  342. aiMetadata={{
  343. projectRef: project?.ref,
  344. connectionString: project?.connectionString,
  345. orgSlug: org?.slug,
  346. }}
  347. selectedFileId={selectedFileId}
  348. setSelectedFileId={setSelectedFileId}
  349. />
  350. <Form {...form}>
  351. <form
  352. onSubmit={form.handleSubmit(onSubmit)}
  353. className="flex items-center bg-background-muted justify-end p-4 border-t bg-surface-100 gap-3"
  354. >
  355. <div className="flex items-center gap-3">
  356. <Label htmlFor="functionName">Function name</Label>
  357. <FormField
  358. control={form.control}
  359. name="functionName"
  360. render={({ field }) => (
  361. <FormItem className="flex flex-col gap-0 m-0">
  362. <div className="flex items-center">
  363. <FormControl>
  364. <Input
  365. id="functionName"
  366. type="text"
  367. size={'large'}
  368. placeholder="Give your function a name..."
  369. className="w-[250px]"
  370. {...field}
  371. />
  372. </FormControl>
  373. {form.formState.errors.functionName && (
  374. <Tooltip>
  375. <TooltipTrigger>
  376. <AlertCircle className="w-4 h-4 text-destructive ml-2" />
  377. </TooltipTrigger>
  378. <TooltipContent>
  379. {form.formState.errors.functionName.message}
  380. </TooltipContent>
  381. </Tooltip>
  382. )}
  383. </div>
  384. </FormItem>
  385. )}
  386. />
  387. </div>
  388. <Button
  389. loading={isDeploying}
  390. size="medium"
  391. disabled={files.length === 0 || isDeploying}
  392. onClick={handleDeploy}
  393. >
  394. Deploy function
  395. </Button>
  396. </form>
  397. </Form>
  398. <PreventNavigationOnUnsavedChanges hasChanges={hasUnsavedChanges && !hasDeployed} />
  399. </PageLayout>
  400. )
  401. }
  402. NewFunctionPage.getLayout = (page: React.ReactNode) => {
  403. return (
  404. <DefaultLayout>
  405. <EdgeFunctionsLayout title="New">{page}</EdgeFunctionsLayout>
  406. </DefaultLayout>
  407. )
  408. }
  409. export default NewFunctionPage